Repository navigation
feat(pbs): dial builders outside the config for ePBS requests - #506
Merged
Merged
Conversation
Pin the lighthouse crates to sigp/lighthouse unstable at 31d8cfd, whose gloas containers hash as EIP-7495 progressive containers, matching the consensus specs. Mirror lighthouse's own [patch.crates-io] entries for the progressive ssz stack (a consumer does not inherit a dependency's patches) and pin the blstrs_plus patch to an explicit rev. lighthouse dropped `TestRandom` for an `arbitrary` generator, so `TestRandomSeed` now draws from `arbitrary::Arbitrary`. Validated BLS points do not randomize under `arbitrary`, so tests that need a real key use `BlsSecretKey::random()`. Also adapt to `ForkName::Heze` and to `ExecutionRequests` no longer implementing `ssz::Decode`.
Tests discovered a free port, dropped the listener and let the server rebind it, leaving a window where another process could take the port. PbsService and SigningService gain `run_with_listener`, the mock SSV servers take a bound listener, and the legacy suites hand their listeners straight to the server. Also add `wait_for_ready`, which polls /status instead of sleeping a fixed 100 ms.
From the Gloas fork the beacon node calls three builder-API endpoints on
Commit-Boost instead of get_header and get_payload:
- POST /eth/v1/builder/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/{proposer_pubkey}
sends the request to the one relay its auth data names and returns that
relay's bid (200), its 400 or 401, or 204 when it has no bid or fails.
Commit-Boost does not validate or rank the bid: the beacon node checks it,
and each request names exactly one builder.
- POST /eth/v1/builder/builder_preferences/{proposer_pubkey} forwards to the
relay the auth data names and returns that builder's answer.
- POST /eth/v1/builder/beacon_blocks forwards the SSZ block bytes unparsed to
every configured relay and answers 202 when one accepts.
Requests must carry Eth-Consensus-Version: gloas. A body without
Content-Type is JSON and a request without Accept gets JSON, as
builder-specs requires. Bid requests need Date-Milliseconds and
X-Timeout-Ms; Commit-Boost clamps the deadline to one slot, keeps
proposer_deadline_buffer_ms back for the return trip and sends the rest to
the builder as its own X-Timeout-Ms, or returns 204 when nothing is left.
Routing follows builder-specs #168: auth data matches a relay when it equals
the lowercase hostname of the relay URL. The first match in config order wins
and unmatched auth data is a 400. Commit-Boost never verifies auth data; the
builder does. Relay-supplied amounts are saturated.
The new operator page (docs/get_started/epbs.md) covers writing each
validator key's builder config through the keymanager API, with a
copy-paste call, plus routing, timing, metrics and troubleshooting.
Unreleased, shipping from v0.12.0-rc1.
condition and lint test
Auth data that is an http(s) URL now also routes to the relay entry with the same scheme, host and port. Either form may end in `?` and parameters for the builder: Commit-Boost routes on the part before `?` and forwards the signed auth, parameters included, unchanged. When no relay entry matches, Commit-Boost dials the builder the auth data names for a bid or preferences request: `https://<hostname>`, or the URL. The target is resolved once, and the lookup and the dial share the request's budget. A name that does not resolve, or any address that is not public unicast, gets 400; a lookup or dial setup that runs out of time counts as a builder that did not answer. At most 32 lookups run at once, since a timed-out lookup keeps its blocking thread. The dial goes only to the checked addresses, with no proxy and no redirects. A request from a Commit-Boost never leads to a dial, so a dial that reaches a Commit-Boost, this one included, goes no further. Auth data, a dialed builder's error body and a dial target come from the request, so they are logged escaped, the body capped at 1 KiB and the target as its origin. The signed block gets 202 once it has been forwarded, whatever the builders answer, since the beacon node gossips it anyway. A block whose bid came from a dialed builder, which is not a relay entry, would otherwise always get 500. When no builder accepts the block, Commit-Boost logs a warning.
JasonVranek
added this pull request to stack #507
October 7, 2026 04:11
proposer_deadline_buffer_ms must now be greater than 0 as well as less than one slot. With 0 the builder gets the whole deadline, so a bid it sends at the deadline reaches the beacon node late. The range is one check in PbsConfig::validate, so the service, a hot reload and a custom PBS module's loader all refuse it. The test fixtures use the default, 50. The metrics catalog documents the ePBS endpoint values, the codes the ePBS endpoints return to the beacon node, the relay_id="dial" label, and that ePBS bids set the relay gauges. The ePBS page corrects two builder config bullets: a top-level min_bid does not floor an entry that sets its own, and the per-client max_execution_payment defaults are dropped. A builder Commit-Boost refuses to dial is not counted under relay_id="dial".
ninaiiad
reviewed
Oct 8, 2026
A client per dial existed only because resolve_to_addrs pins addresses per client. The shared client's resolver now checks every address it resolves, so a dial still connects only to checked addresses, and a second dial to a builder reuses the first one's connection instead of a new TCP and TLS handshake. Dial hosts are chosen by the request, so the client keeps at most one idle connection per host, for one slot. The check before the dial is unchanged, so a blocked target still gets 400 before anything is sent.
This was referenced Oct 9, 2026
vladimir-ea
reviewed
Oct 10, 2026
vladimir-ea
reviewed
Oct 10, 2026
vladimir-ea
reviewed
Oct 10, 2026
decode_auth_data_url only ever gets the auth data address, the part before any `?`, so its parameter and doc now say so. dial_target's comparison of the parsed host with the address passes only a hostname already in the builder-specs default form, since parsing also accepts a port, userinfo, a path, upper case and numeric IPv4.
A dial to a hostname looked the host up twice in a row on the request path: once in dial_relay's check, and again in DialResolver when the connection opened. The resolver now takes the check's addresses while they are under 2 s old, checking them again, and looks the host up itself otherwise, so a later connection still sees a DNS change. The addresses are kept with port 0, as the resolver's own lookup returns them, so a dial to the same host on another port connects to its own.
Routing: a request whose auth data names a builder looks first among the relays serving the key, then among [[relays]] in config order, before dialing. A key in a mux whose auth data named a relay configured only in [[relays]] was dialed at https://<host>:443 without that entry's port or headers, or, on a private address, refused with 400; it now reaches the relay through its entry. Relays listed only in a mux are not searched for other keys, since their entries carry settings meant for that mux's keys; such a relay is dialed like any builder outside the config. Signed block: builder-specs makes JSON the default, and Lighthouse forwards the signed block as JSON, but Commit-Boost answered anything but SSZ with 415, so no builder received a Lighthouse block through it (415 on 17 of 17 blocks on a devnet). The block is forwarded undecoded in the encoding it arrived in, labelled JSON when it is JSON or unlabelled, SSZ when it is SSZ; other types are 415. Own host: on a validator client whose builder URL is Commit-Boost, a key with no builder config sends Commit-Boost's hostname as auth data. When that host resolved to a public address, Commit-Boost dialed it on port 443, where it does not listen; behind a firewall that drops packets, the dial waited out the whole bid timeout. Auth data equal to the host in the request's Host header, in the form of default auth data, is refused with 400 before any lookup. Also: the signed block logs one info line with how many builders accepted it, since a block whose bid came from a dialed builder is accepted by none of the configured ones. The ePBS routes read a builder's error body through one helper, and an empty one on the bid route reads "expected 200 or 204". A dial lookup that fails to run is logged. The cb-pbs testing flag moves to dev-dependencies, so a workspace build never compiles in the switch that turns off the dial check. Each change has a test; others pin the lookup cap holding its slot, a dial never carrying a configured relay's headers, a 204 when no lookup slot is free, and the lookup time taken from the builder's timeout. The docs correct the claims on the signed block, default auth data, routing and dial counting, the lookup time in X-Timeout-Ms, 500 on an internal error, and 555 on get_header_stream.
JasonVranek
added a commit
that referenced
this pull request
Oct 12, 2026
Brings in 9280d92. Hand resolutions: - execution_payload_bid.rs keeps this branch's rule that a builder's 400 or 401 reaches the beacon node only when the stream has no bid, with #506's log field; - epbs.md keeps the stream troubleshooting rows and drops the two rows #506 removed (the 415 and the "no builder accepted" warning); - metrics-catalog.md keeps this branch's rows, which already cover the stream endpoints. The ePBS stream handshake test now expects the relay's own API key, since #506 gives the mock relays a key distinct from the beacon node's.
vladimir-ea
approved these changes
Oct 13, 2026
ManuelBilbao
approved these changes
Oct 13, 2026
JasonVranek
added a commit
that referenced
this pull request
Oct 13, 2026
JasonVranek
added a commit
that referenced
this pull request
Oct 14, 2026
A relay with get_header = "stream" now also streams its ePBS bids. Each
bid request addressed to it opens
ws(s)://<relay>/eth/v1/builder/execution_payload_bid_stream/{slot}/{parent_hash}/{parent_root}/{proposer_pubkey}
on the PBS stream reader, with the SignedBuilderRequestAuth as padded
standard base64 of its SSZ in X-Request-Auth, and races it against the
relay's HTTP request. The bid with the higher value + execution_payment
wins, unclamped, and the stream's bid wins a tie. A builder's HTTP 400
or 401 reaches the proposer only when neither leg has a bid. A builder
Commit-Boost dials is asked over HTTP only. The stream's bid keeps its
SSZ frame, so a beacon node that asks for SSZ gets it unchanged. Each
frame is decoded on arrival, so the stream holds only its newest bid.
A streamed request can last until the deadline less
proposer_deadline_buffer_ms, which #506 requires to be above 0. A failed
stream is logged and counted in one place, and the race's docs mark what
is unreleased.
The stream reports the bid stream series under
endpoint="get_execution_payload_bid_stream". The ePBS page gets a Bid
streaming section and two troubleshooting rows: a relay that streams
get_header but not ePBS bids, and a stream that times out or cannot
connect.
JasonVranek
added a commit
that referenced
this pull request
Oct 14, 2026
Brings in 9280d92. Hand resolutions: - execution_payload_bid.rs keeps this branch's rule that a builder's 400 or 401 reaches the beacon node only when the stream has no bid, with #506's log field; - epbs.md keeps the stream troubleshooting rows and drops the two rows #506 removed (the 415 and the "no builder accepted" warning); - metrics-catalog.md keeps this branch's rows, which already cover the stream endpoints. The ePBS stream handshake test now expects the relay's own API key, since #506 gives the mock relays a key distinct from the beacon node's.
JasonVranek
added a commit
that referenced
this pull request
Oct 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Builds on #505. There, a bid or preferences request whose auth data matches no relay entry gets
400, so a validator key whose builder config names a builder the operator has not added to Commit-Boost gets no bids from it. This PR has Commit-Boost dial the builder the auth data names, behind an SSRF guard, and routes auth data written as a builder URL. It also answers the signed block with202once it has been forwarded, whatever the builders answer, which addresses a review comment on #505.What it does
httporhttpsURL matches the first relay entry with the same scheme, host and port. The path and the pubkey in the relay URL do not count.?and form-encoded parameters for the builder, such asbuilder-a.example.com?filter=1, a way for a proposer to state a preference like transaction filtering. Commit-Boost routes on the part before?and forwards the signed auth, parameters included, unchanged, so the parameters arrive signed by the proposer. A builder that does not accept them answers400, so a proposer sets them only for builders that accept them. Without a?, routing is unchanged.https://<hostname>on the default port, or the URL's scheme, host and port. There is no setting to turn it off. Dialed requests count underrelay_id="dial", and each dial logs the builder's origin and the addresses it connects to. A key without builder config sends Commit-Boost's own hostname, so Commit-Boost dialshttps://<its hostname>on port 443:400when that hostname resolves to a loopback or private address, as it usually does, and otherwise one request to whatever serves HTTPS on that host, which is Commit-Boost only behind a TLS proxy, where the loop guard below stops it.202once it has been forwarded, whatever the builders answer. This answers a review comment on epbs builder api endpoints #505: relays may do nothing with these requests, so an apparent failure should not be reported as one, and the beacon node gossips the block anyway. The block still goes only to configured relays, since Commit-Boost keeps no auction state; a dialed builder gets it over gossip.The ePBS docs page gets a section on builders outside the config, which also says what a key without builder config gets, the URL form and
?parameters in the routing reference,500in the metrics table for preferences only, and troubleshooting rows for both400s and for that warning.Scope
Commit-Boost now makes outbound requests to hosts named in request data. The proposer signs that data, but Commit-Boost does not verify the signature, so the target is treated as untrusted, and anyone who can reach Commit-Boost's PBS port can choose it:
timeout_register_validator_msfor preferences). If the name does not resolve, the answer is400and nothing is dialed. A lookup or dial setup that runs out of time counts as a builder that did not answer: no bid (204), or500for preferences.resolve_to_addrs), ignoresHTTP(S)_PROXYandALL_PROXY(a proxy would resolve the host again), follows no redirects, and sends none of the operator's relayheaders.X-CommitBoost-Version, which every Commit-Boost dial sends, never leads to a dial, so a dial that reaches a Commit-Boost, this one included, goes no further. Configured relays still serve such a request, so chained Commit-Boosts keep working.user:password@.Testing
cargo test --all-features: 402 passed (392 on part 1). Unit: URL-form auth data decoding (onlyhttpandhttps; opaque data such asbuilder-a:prodandhost:portparse as URLs and are refused), the dial target for each auth data form (URL, hostname, IPv6 hostname, a hostname with?parameters, an internal address, a non-hostname, a non-HTTP scheme), every resolved address checked, the lookup cap refusing a hostname but not an IP address, the dial pinned to the given address with redirects refused, the blocked-address table, and URL-form routing to relay entries and routing on the part before?. End to end: a bid dialed with its auth,?parameters included, unchanged, a bid naming Commit-Boost's own URL dialed once and answered with the second hop's400, a request from a Commit-Boost not dialed but still served by a configured relay, preferences dialed, an unmatchedlocalhostrefused as loopback, and no lookup once the deadline has passed. A builder's400and401reach the beacon node even with an error body over the 1 KiB read cap. The signed block gets202when every builder rejects it, and a pretty-printed JSON bid reaches the beacon node byte for byte.204, putting the500for a block no builder accepted back fails the all-reject test, and removing epbs builder api endpoints #505's passthrough arm fails only the passthrough test.HTTP_PROXYin a process that runs other tests in parallel), the escaping in logs and the 1 KiB cap on a logged error body, the refusal when the dial setup leaves no time, and the lookup's timing: its timeout and the time it leaves the dial (a local lookup finishes inside tokio's 1 ms timer tick, so a name slow enough to matter needs the network).--assert dial; Docker addresses are all private). Each key's auth data washttp://buildoor:8080, which no relay entry matches, so Commit-Boost dialed buildoor 95 times, for bid and preferences requests. All 17 observed slots were built from those bids, no dial was refused, and none looped.